/**
 * @webxr-input-profiles/motion-controllers 1.0.0 https://github.com/immersive-web/webxr-input-profiles
 */

const Constants = {
    Handedness: Object.freeze({
        NONE: 'none',
        LEFT: 'left',
        RIGHT: 'right'
    }),

    ComponentState: Object.freeze({
        DEFAULT: 'default',
        TOUCHED: 'touched',
        PRESSED: 'pressed'
    }),

    ComponentProperty: Object.freeze({
        BUTTON: 'button',
        X_AXIS: 'xAxis',
        Y_AXIS: 'yAxis',
        STATE: 'state'
    }),

    ComponentType: Object.freeze({
        TRIGGER: 'trigger',
        SQUEEZE: 'squeeze',
        TOUCHPAD: 'touchpad',
        THUMBSTICK: 'thumbstick',
        BUTTON: 'button'
    }),

    ButtonTouchThreshold: 0.05,

    AxisTouchThreshold: 0.1,

    VisualResponseProperty: Object.freeze({
        TRANSFORM: 'transform',
        VISIBILITY: 'visibility'
    })
};

/**
 * @description Static helper function to fetch a JSON file and turn it into a JS object
 * @param {string} path - Path to JSON file to be fetched
 */
async function fetchJsonFile(path) {
    const response = await fetch(path);
    if (!response.ok) {
        throw new Error(response.statusText);
    } else {
        return response.json();
    }
}

async function fetchProfilesList(basePath) {
    if (!basePath) {
        throw new Error('No basePath supplied');
    }

    const profileListFileName = 'profilesList.json';
    const profilesList = await fetchJsonFile(`${basePath}/${profileListFileName}`);
    return profilesList;
}

async function fetchProfile(xrInputSource, basePath, defaultProfile = null, getAssetPath = true) {
    if (!xrInputSource) {
        throw new Error('No xrInputSource supplied');
    }

    if (!basePath) {
        throw new Error('No basePath supplied');
    }

    // Get the list of profiles
    const supportedProfilesList = await fetchProfilesList(basePath);

    // Find the relative path to the first requested profile that is recognized
    let match;
    xrInputSource.profiles.some((profileId) => {
        const supportedProfile = supportedProfilesList[profileId];
        if (supportedProfile) {
            match = {
                profileId,
                profilePath: `${basePath}/${supportedProfile.path}`,
                deprecated: !!supportedProfile.deprecated
            };
        }
        return !!match;
    });

    if (!match) {
        if (!defaultProfile) {
            throw new Error('No matching profile name found');
        }

        const supportedProfile = supportedProfilesList[defaultProfile];
        if (!supportedProfile) {
            throw new Error(`No matching profile name found and default profile "${defaultProfile}" missing.`);
        }

        match = {
            profileId: defaultProfile,
            profilePath: `${basePath}/${supportedProfile.path}`,
            deprecated: !!supportedProfile.deprecated
        };
    }

    const profile = await fetchJsonFile(match.profilePath);

    let assetPath;
    if (getAssetPath) {
        let layout;
        if (xrInputSource.handedness === 'any') {
            layout = profile.layouts[Object.keys(profile.layouts)[0]];
        } else {
            layout = profile.layouts[xrInputSource.handedness];
        }
        if (!layout) {
            throw new Error(
                `No matching handedness, ${xrInputSource.handedness}, in profile ${match.profileId}`
            );
        }

        if (layout.assetPath) {
            assetPath = match.profilePath.replace('profile.json', layout.assetPath);
        }
    }

    return {profile, assetPath};
}

/** @constant {Object} */
const defaultComponentValues = {
    xAxis: 0,
    yAxis: 0,
    button: 0,
    state: Constants.ComponentState.DEFAULT
};

/**
 * @description Converts an X, Y coordinate from the range -1 to 1 (as reported by the Gamepad
 * API) to the range 0 to 1 (for interpolation). Also caps the X, Y values to be bounded within
 * a circle. This ensures that thumbsticks are not animated outside the bounds of their physical
 * range of motion and touchpads do not report touch locations off their physical bounds.
 * @param {number} x The original x coordinate in the range -1 to 1
 * @param {number} y The original y coordinate in the range -1 to 1
 */
function normalizeAxes(x = 0, y = 0) {
    let xAxis = x;
    let yAxis = y;

    // Determine if the point is outside the bounds of the circle
    // and, if so, place it on the edge of the circle
    const hypotenuse = Math.sqrt((x * x) + (y * y));
    if (hypotenuse > 1) {
        const theta = Math.atan2(y, x);
        xAxis = Math.cos(theta);
        yAxis = Math.sin(theta);
    }

    // Scale and move the circle so values are in the interpolation range.  The circle's origin moves
    // from (0, 0) to (0.5, 0.5). The circle's radius scales from 1 to be 0.5.
    const result = {
        normalizedXAxis: (xAxis * 0.5) + 0.5,
        normalizedYAxis: (yAxis * 0.5) + 0.5
    };
    return result;
}

/**
 * Contains the description of how the 3D model should visually respond to a specific user input.
 * This is accomplished by initializing the object with the name of a node in the 3D model and
 * property that need to be modified in response to user input, the name of the nodes representing
 * the allowable range of motion, and the name of the input which triggers the change. In response
 * to the named input changing, this object computes the appropriate weighting to use for
 * interpolating between the range of motion nodes.
 */
class VisualResponse {
    constructor(visualResponseDescription) {
        this.componentProperty = visualResponseDescription.componentProperty;
        this.states = visualResponseDescription.states;
        this.valueNodeName = visualResponseDescription.valueNodeName;
        this.valueNodeProperty = visualResponseDescription.valueNodeProperty;

        if (this.valueNodeProperty === Constants.VisualResponseProperty.TRANSFORM) {
            this.minNodeName = visualResponseDescription.minNodeName;
            this.maxNodeName = visualResponseDescription.maxNodeName;
        }

        // Initializes the response's current value based on default data
        this.value = 0;
        this.updateFromComponent(defaultComponentValues);
    }

    /**
     * Computes the visual response's interpolation weight based on component state
     * @param {Object} componentValues - The component from which to update
     * @param {number} xAxis - The reported X axis value of the component
     * @param {number} yAxis - The reported Y axis value of the component
     * @param {number} button - The reported value of the component's button
     * @param {string} state - The component's active state
     */
    updateFromComponent({
                            xAxis, yAxis, button, state
                        }) {
        const {normalizedXAxis, normalizedYAxis} = normalizeAxes(xAxis, yAxis);
        switch (this.componentProperty) {
            case Constants.ComponentProperty.X_AXIS:
                this.value = (this.states.includes(state)) ? normalizedXAxis : 0.5;
                break;
            case Constants.ComponentProperty.Y_AXIS:
                this.value = (this.states.includes(state)) ? normalizedYAxis : 0.5;
                break;
            case Constants.ComponentProperty.BUTTON:
                this.value = (this.states.includes(state)) ? button : 0;
                break;
            case Constants.ComponentProperty.STATE:
                if (this.valueNodeProperty === Constants.VisualResponseProperty.VISIBILITY) {
                    this.value = (this.states.includes(state));
                } else {
                    this.value = this.states.includes(state) ? 1.0 : 0.0;
                }
                break;
            default:
                throw new Error(`Unexpected visualResponse componentProperty ${this.componentProperty}`);
        }
    }
}

class Component {
    /**
     * @param {Object} componentId - Id of the component
     * @param {Object} componentDescription - Description of the component to be created
     */
    constructor(componentId, componentDescription) {
        if (!componentId
            || !componentDescription
            || !componentDescription.visualResponses
            || !componentDescription.gamepadIndices
            || Object.keys(componentDescription.gamepadIndices).length === 0) {
            throw new Error('Invalid arguments supplied');
        }

        this.id = componentId;
        this.type = componentDescription.type;
        this.rootNodeName = componentDescription.rootNodeName;
        this.touchPointNodeName = componentDescription.touchPointNodeName;

        // Build all the visual responses for this component
        this.visualResponses = {};
        Object.keys(componentDescription.visualResponses).forEach((responseName) => {
            const visualResponse = new VisualResponse(componentDescription.visualResponses[responseName]);
            this.visualResponses[responseName] = visualResponse;
        });

        // Set default values
        this.gamepadIndices = Object.assign({}, componentDescription.gamepadIndices);

        this.values = {
            state: Constants.ComponentState.DEFAULT,
            button: (this.gamepadIndices.button !== undefined) ? 0 : undefined,
            xAxis: (this.gamepadIndices.xAxis !== undefined) ? 0 : undefined,
            yAxis: (this.gamepadIndices.yAxis !== undefined) ? 0 : undefined
        };
    }

    get data() {
        const data = {id: this.id, ...this.values};
        return data;
    }

    /**
     * @description Poll for updated data based on current gamepad state
     * @param {Object} gamepad - The gamepad object from which the component data should be polled
     */
    updateFromGamepad(gamepad) {
        // Set the state to default before processing other data sources
        this.values.state = Constants.ComponentState.DEFAULT;

        // Get and normalize button
        if (this.gamepadIndices.button !== undefined
            && gamepad.buttons.length > this.gamepadIndices.button) {
            const gamepadButton = gamepad.buttons[this.gamepadIndices.button];
            this.values.button = gamepadButton.value;
            this.values.button = (this.values.button < 0) ? 0 : this.values.button;
            this.values.button = (this.values.button > 1) ? 1 : this.values.button;

            // Set the state based on the button
            if (gamepadButton.pressed || this.values.button === 1) {
                this.values.state = Constants.ComponentState.PRESSED;
            } else if (gamepadButton.touched || this.values.button > Constants.ButtonTouchThreshold) {
                this.values.state = Constants.ComponentState.TOUCHED;
            }
        }

        // Get and normalize x axis value
        if (this.gamepadIndices.xAxis !== undefined
            && gamepad.axes.length > this.gamepadIndices.xAxis) {
            this.values.xAxis = gamepad.axes[this.gamepadIndices.xAxis];
            this.values.xAxis = (this.values.xAxis < -1) ? -1 : this.values.xAxis;
            this.values.xAxis = (this.values.xAxis > 1) ? 1 : this.values.xAxis;

            // If the state is still default, check if the xAxis makes it touched
            if (this.values.state === Constants.ComponentState.DEFAULT
                && Math.abs(this.values.xAxis) > Constants.AxisTouchThreshold) {
                this.values.state = Constants.ComponentState.TOUCHED;
            }
        }

        // Get and normalize Y axis value
        if (this.gamepadIndices.yAxis !== undefined
            && gamepad.axes.length > this.gamepadIndices.yAxis) {
            this.values.yAxis = gamepad.axes[this.gamepadIndices.yAxis];
            this.values.yAxis = (this.values.yAxis < -1) ? -1 : this.values.yAxis;
            this.values.yAxis = (this.values.yAxis > 1) ? 1 : this.values.yAxis;

            // If the state is still default, check if the yAxis makes it touched
            if (this.values.state === Constants.ComponentState.DEFAULT
                && Math.abs(this.values.yAxis) > Constants.AxisTouchThreshold) {
                this.values.state = Constants.ComponentState.TOUCHED;
            }
        }

        // Update the visual response weights based on the current component data
        Object.values(this.visualResponses).forEach((visualResponse) => {
            visualResponse.updateFromComponent(this.values);
        });
    }
}

/**
 * @description Builds a motion controller with components and visual responses based on the
 * supplied profile description. Data is polled from the xrInputSource's gamepad.
 * @author Nell Waliczek / https://github.com/NellWaliczek
 */
class MotionController {
    /**
     * @param {Object} xrInputSource - The XRInputSource to build the MotionController around
     * @param {Object} profile - The best matched profile description for the supplied xrInputSource
     * @param {Object} assetUrl
     */
    constructor(xrInputSource, profile, assetUrl) {
        if (!xrInputSource) {
            throw new Error('No xrInputSource supplied');
        }

        if (!profile) {
            throw new Error('No profile supplied');
        }

        this.xrInputSource = xrInputSource;
        this.assetUrl = assetUrl;
        this.id = profile.profileId;

        // Build child components as described in the profile description
        this.layoutDescription = profile.layouts[xrInputSource.handedness];
        this.components = {};
        Object.keys(this.layoutDescription.components).forEach((componentId) => {
            const componentDescription = this.layoutDescription.components[componentId];
            this.components[componentId] = new Component(componentId, componentDescription);
        });

        // Initialize components based on current gamepad state
        this.updateFromGamepad();
    }

    get gripSpace() {
        return this.xrInputSource.gripSpace;
    }

    get targetRaySpace() {
        return this.xrInputSource.targetRaySpace;
    }

    /**
     * @description Returns a subset of component data for simplified debugging
     */
    get data() {
        const data = [];
        Object.values(this.components).forEach((component) => {
            data.push(component.data);
        });
        return data;
    }

    /**
     * @description Poll for updated data based on current gamepad state
     */
    updateFromGamepad() {
        Object.values(this.components).forEach((component) => {
            component.updateFromGamepad(this.xrInputSource.gamepad);
        });
    }
}

export {Constants, MotionController, fetchProfile, fetchProfilesList};
